寫資料庫引擎之前,網路層得先站穩。Redis 之所以快,底層事件模型佔了很大一部分;我雖然不用在 Go 裡手刻 epoll,但還是得先做出一個可以同時處理多個 client、關機時也不會亂收尾的 TCP Server。
今天先把 TCP Server 跟 Graceful Shutdown 做起來。這塊如果一開始偷懶,後面接 RESP parser 和命令分發時會很痛。
在生產環境中,伺服器可能隨時因為部署更新、作業系統維護或配置調整而重啟。如果直接強制終止進程(例如 kill -9),會造成以下問題:
Graceful Shutdown 的核心流程是:
SIGINT 或 SIGTERM)。我在 code/server/server.go 中實作伺服器,先把幾個最基本但不能少的欄位放進來:
我定義了 Server 結構,包含用於追蹤連線與控制併發的核心欄位:
type Server struct {
addr string
listener net.Listener
wg sync.WaitGroup // 追蹤當前正在執行中的 Goroutine
quit chan struct{} // 用於通知伺服器關閉的 Channel
inShutdown int32 // 標記伺服器是否處於關閉狀態(原子操作)
mu sync.Mutex
conns map[net.Conn]struct{} // 追蹤所有活著的客戶端連線
}
這邊我一開始寫的時候忘了加 sync.Mutex 保護 map,連線一多直接 panic,查了半天才發現是併發讀寫 map 炸了,真的不能鐵齒。
當呼叫 Start() 時,使用 Go 標準庫的 net.Listen 啟動 TCP 監聽。隨後進入一個無窮循環(Event Loop),不斷接收新的連線:
func (s *Server) Start() error {
l, err := net.Listen("tcp", s.addr)
if err != nil {
return fmt.Errorf("監聽埠口失敗: %w", err)
}
s.listener = l
log.Printf("Redis Clone 伺服器已啟動,監聽於 %s", s.addr)
for {
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.quit:
// 若是因為伺服器主動關閉導致 Accept 報錯,這屬於預期行為,直接退出
return nil
default:
}
log.Printf("接受連線失敗: %v", err)
continue
}
s.wg.Add(1)
go s.handleConnection(conn) // 啟動新 goroutine 處理連線
}
}
在 handleConnection 中,需要動態記錄連線的建立與關閉。這可以防止伺服器在 Graceful Shutdown 時漏掉部分連線:
func (s *Server) handleConnection(conn net.Conn) {
defer s.wg.Done()
defer conn.Close()
if s.isShuttingDown() {
return
}
// 追蹤連線
s.trackConn(conn, true)
defer s.trackConn(conn, false)
log.Printf("新連線建立: %s", conn.RemoteAddr().String())
// 暫時做個簡單的 Echo,隨後會在此處整合我們的 RESP 解析器
buf := make([]byte, 1024)
// ... 讀寫邏輯 ...
}
當關閉指令觸發時,Shutdown 函數會被呼叫,它執行以下關鍵步驟:
func (s *Server) Shutdown(ctx context.Context) error {
// 1. 設定關閉標記,並關閉 quit channel 停止 Accept 循環
atomic.StoreInt32(&s.inShutdown, 1)
close(s.quit)
var err error
if s.listener != nil {
err = s.listener.Close() // 關閉 Listener,此時 Accept 會立即返回錯誤
}
// 2. 主動關閉當前所有已建立的客戶端連線
s.mu.Lock()
for conn := range s.conns {
conn.Close()
}
s.mu.Unlock()
// 3. 等待所有連線處理goroutine 完成,或直到 Context 逾時
c := make(chan struct{})
go func() {
s.wg.Wait()
close(c)
}()
select {
case <-ctx.Done():
return ctx.Err() // 逾時退出,防止程式無限期卡死
case <-c:
log.Println("伺服器已安全關閉")
return err
}
}
在 code/main.go 中,使用 Go 的 os/signal 監聽來自作業系統的 SIGINT (Ctrl+C) 和 SIGTERM 訊號,並設定 5 秒的關閉安全時間:
func main() {
srv := server.NewServer(":6379")
go func() {
if err := srv.Start(); err != nil {
log.Fatalf("伺服器啟動失敗: %v", err)
}
}()
// 監聽作業系統中斷訊號
shutdownSignal := make(chan os.Signal, 1)
signal.Notify(shutdownSignal, syscall.SIGINT, syscall.SIGTERM)
sig := <-shutdownSignal
log.Printf("接收到關閉訊號 (%v),正在安全關閉...", sig)
// 設定 5 秒的關閉逾時時間
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("伺服器關閉時發生錯誤: %v", err)
} else {
log.Println("伺服器安全退出")
}
}
我們可以編譯並執行伺服器:
cd code
go run main.go
打開另一個終端機,使用 nc (Netcat) 連線到 6379 埠口:
nc localhost 6379
當輸入任何字元,伺服器都會將其回傳。當在伺服器端按下 Ctrl + C,會看到以下日誌輸出,顯示伺服器正以 Graceful Shutdown 流程安全結束:
2026/08/11 22:18:00 Redis Clone 伺服器已啟動,監聽於 :6379
2026/08/11 22:18:05 新連線建立: 127.0.0.1:51234
^C2026/08/11 22:18:10 接收到關閉訊號 (interrupt),正在安全關閉...
2026/08/11 22:18:10 伺服器已安全關閉
2026/08/11 22:18:10 伺服器安全退出
來驗收一下今天寫的 TCP Server!先在終端機啟動我們的伺服器:
$ go run ./code/main.go
# 預期輸出:Server is running on :6379 之類的 log
接著開另一個終端機視窗,用 nc 連上去看看:
$ nc localhost 6379
這時候回到原本的伺服器視窗,應該會看到有新連線進來:
# 預期輸出:Accepted connection from 127.0.0.1:xxxx
最後我們在伺服器視窗按 Ctrl+C 測試 Graceful Shutdown,看看伺服器有沒有把該關的關好:
# 預期輸出:
# Graceful shutdown starting...
# Waiting for active connections to close...
# Graceful shutdown completed.
一切順利!連線跟關機看起來都沒問題。
今天先把 TCP Server 跑起來,也補了連線追蹤和關機收尾。sync.WaitGroup、原子標記、訊號監聽看起來都很基礎,但少一個就很容易關不乾淨。
明天換研究 RESP 協定。光看那些prefix byte就覺得會有不少細節要踩,明天見囉!